Build 447 v0.9.0 - Technical Changelog (Summary)

## Menstrual / hygiene: pie menu NullReference (Use tampon / pad / cup)

- **Issue (before):** In "Hygiene_UseTampon", "Hygiene_UsePad", and "Hygiene_UseCup", "Definition.Test" accessed "simData.CreatedSim.Posture" without checking that "simData" or "CreatedSim" were valid. During game transitions (world load, return to main menu, states where the Sim peg doll is not yet available), "CreatedSim" could be "null": the engine calls "Test" for each entry while building the pie menu on the Sim → repeated **"NullReferenceException"** in the same second for each period protection type (stacks of "ScriptError_*.xml" with different prefixes).

- **Change:** Immediately after "SimData.Get", added an early-exit guard:
  "if (simData == null || simData.CreatedSim == null || simData.CreatedSim.HasBeenDestroyed) return false;"
  in the three files under "Oniki.Interactions": "Hygiene_UseTampon.cs", "Hygiene_UsePad.cs", "Hygiene_UseCup.cs".

- **Result:** During those states the test simply returns *not available* instead of throwing; no script error spam from this cause.

---

## Services: "KWServices.Simulate" — avoid NRE when "Services.sInstance" is null

- **Issue (before):** In "Simulate()", if "Gameplay.Services.Services.sInstance != this" but "sInstance" had been set to "null" by the engine (long loads, travel to secondary worlds e.g. Sims University, return to menu), the code called "sInstance.Destroy()" on a null reference → **"NullReferenceException"** on every loop iteration; identical logs repeated (e.g. 11 files in the same second) and possible apparent progress bar stall.
- **Change:** "Oniki.Services/KWServices.cs" — before "Destroy()", check "if (sInstance != null)"; in any case then "sInstance = this" to realign the KW singleton.
- **Result:** No exception on that path; the KW simulator restores the global reference without dereferencing null.

---

## Pregnancy: MapTagFertile (toggle) runtime visibility

- **Issue (before):** The "MapTagFertile" option (Pregnancy menu) effectively enabled or disabled *adding* the "Fertile" "SimTag" in "Womb.AddFertileSim": turning the toggle off stopped tags from being registered; turning it back on often left map/HUD effect delayed or inconsistent (phase-dependent, not a true real-time show/hide).
- **Change:**
  - "Oniki.Gameplay.MapTags.SimTag": for the ""Fertile"" key, override "IsActive" to return "Main.Settings.GetBool("MapTagFertile")"; other keys (e.g. Whore) keep "IsActive" always true.
  - "Oniki.Gameplay.Womb.AddFertileSim": removed the initial block on "MapTagFertile" so fertile tags are registered as before when fertility/cycle and viewer id are valid; **map/HUD visibility** goes through the "IsActive" flag (as "MapTagController" consumes it in the engine, which does not show tags with "IsActive == false").
- **Result:** The toggle immediately controls *appearance* of "Fertile" markers (verified including rapid ON/OFF without waiting for phase changes). No new setting or STBL key: existing "MapTagFertile" is used.

---

## Menstrual cycle: **Extended Birth Control Pill** ("ExtendedBirthControlPill") + fertile moodlet timing

### Extended pill behavior (toggle **OFF** by default)

- **New** persisted bool **"ExtendedBirthControlPill"**, default **"false"** — **Settings → Menstrual cycle** (in **"OptionSettingMenuMenstrualCyclePeriod.cs"**, after **"AutoMenstrualCareToilet"**, before cycle override). **"ResetSettings"** + **"EnsureReleaseRequiredSettingsPresent"** (add-if-missing).
- On **"SimData.OnTakeThePill()"**, after the legacy **"AddCooldown("takethepill", ...)"**, if **"mWomb"** exists → **"Womb.ApplyExtendedBirthControlPillIfEnabled()"** (same **"Contraception" / take-pill object path as before).
- When the setting is **ON**, each dose:
  - clears **"PendingPregnancy"**;
  - if **"mSim.Pregnancy"** is **"KinkyPregnancy"** and **"HourOfPregnancy < 6"** (Sim hours on EA hourly counter; effective range **0–5** after ticks) → **"CancelPregnancy()"** and return (**only** "KinkyPregnancy"; other pregnancy types untouched);
  - otherwise, if the Sim is in the **ovulatory** phase → **"SetPhase(Luteal)"**.
- **Cooldown:** the existing **24h** **"takethepill"** gate is unchanged (still blocks entering ovulation at end of follicular per legacy rules).

### "Womb.cs" implementation notes

- Constant **"kExtendedBirthControlPillCancelPregnancyMaxHourExclusive = 6"** (cancel window uses **"< 6"** on **"HourOfPregnancy"**).
- **"SetPhase"** — **luteal** branch: after **"RemoveFertileSim"**, also remove **"KWBuff.Fertile"** so moodlets stay aligned with “no longer fertile.”

### "KinkyPregnancy.cs"

- Public **"HourOfPregnancy"** getter exposing the underlying EA hourly field (**"mHourOfPregnancy"**) so the pill logic can apply the early-cancel window without reflection.

### Fertile moodlet UX (same wave, related)

- **"KWBuff.Fertile"** is now **ovulatory-only** — not shown during follicular “pre-window,” to avoid misleading **Fertile Period** UI when KW ovulatory impregnation checks do not yet apply or when pill flow will skip ovulation.
- **"Womb.UpdateFollicular":** remove **"KWBuff.Fertile"** if present; **removed** the old branch that added it before ovulation. Ovulatory phase still applies **"KWBuff.Fertile"** as before.

### STBL (full key for hash work)

- **"Oniki.KinkyMod.OptionSettings.ExtendedBirthControlPill"** — menu row label (optional extended tooltip may describe: pending pregnancy cleared, exit from ovulation, **KW pregnancy cancel within first 6 Sim hours** — not vanilla/non-"KinkyPregnancy" pregnancies).

### Code touchpoints

- **"Oniki.Gameplay/Womb.cs"**, **"Oniki.Gameplay/KinkyPregnancy.cs"**, **"Oniki.Gameplay/SimData.cs"**, **"Oniki/Settings.cs"**, **"Oniki.UI/OptionSettingMenuMenstrualCyclePeriod.cs"**.

---

## Debug / KWVirgin: "SetFlag_Virgin" — Remove State Virgin also aligns physical virginity

- **Issue (before):** The debug interaction **Remove State Virgin** ("Oniki.Interactions.Debug.SetFlag_Virgin", "RemoveFlag") set "HadFirstWooHoo" and experience flags on "WooHooSkill", but **did not** set "LostVirginityWithHuman" / "Dog" / "Horse". With "KWVirgin" active, "IsPhysicalVirgin" therefore stayed "true" while the "logical" non-virgin state could already apply: subsequent WooHoos still showed **Virgin** suffixes on outcome messages in "PostWooHoo_Effects" and the "first time" flow tied to physical virginity — reported as a bug after using debug.
- **Change:** In "RemoveFlag", after the other flags/stats, added setting the lost-virginity flag consistent with species (dogs "LostVirginityWithDog", horses "LostVirginityWithHorse", otherwise "LostVirginityWithHuman"), in line with the rest of the mod.
- **Result:** Remove State Virgin updates **both** logical state and KW **physical** state together; no desync between "IsVirgin" / skill experience and "IsPhysicalVirgin" after the debug command.

---

## Localization: virginity feedback in "WooHooSkill.ReportAction"

- **Context:** STBL keys "Oniki.KinkyMod.woohoo/feedbacks:virginfemale" and "Oniki.KinkyMod.woohoo/feedbacks:virginmale" are the **only** ones used in code for notifications when the **partner** is still "IsPhysicalVirgin" (female / vaginal branch and male / vaginal or anal branch); "{0}" = "SkillOwner", "{1}" = "partner" (same order for both).
- **Fix:** Text previously associated with **virginmale** did not reflect that meaning (copy like "new position" not present elsewhere in the DLL as a dedicated path). The **two** strings in the STBL package were **aligned** to the same sense as the female message (who lost virginity = "partner"), without changing code or key hashes.
- **Note:** No new STBL keys; update only values for the two existing entries, in all languages shipped with the release.

---

## Kinky traits: settings submenu, spread sampling, social discovery, XML predicates, reward fix

### Global menu — **“Kinky Traits Settings”** container

- New submenu class **"OptionSettingMenuKinkyTraitsSettings"** (from **"OptionSettingMenuGlobal.cs"**): single Global entry instead of three separate rows.
- **Order inside submenu:** (1) Spread Kinky Traits, (2) sample **"KinkyTraitsSpreadSamplePercent"**, (3) **"DisableKinkyTraitsAutoPicker"**.
- **STBL:** **"Oniki.KinkyMod.OptionSettings.MenuKinkyTraitsSettings.Label"** (fallback EN in code: ""Kinky Traits Settings"").

### **"DisableKinkyTraitsAutoPicker"** (default **"false"**)

- **"Hud.UpdateSkillsPanel":** when **"true"**, skips starting the flow that opens **"MissingTraits" → "AssignNewTraits"** from the Skills/Simology panel on Sim switch.
- **Does not** disable: pie **"SelectKinkyTraits"**, reward/overflow editor, or **"AssignNewTraits"** after KW age-up.
- **"Settings":** default, **"EnsureReleaseRequiredSettingsPresent"**, upgrade/import, **"ResetSettings"**.

### **"KinkyTraitsSpreadSamplePercent"** (**"10"–"100"**, default **"100"**)

- Applies **only** to **"KinkyTraitsState.RunGlobalSpreadPass":** among Sims **already eligible** for spread (same filters as before; active household still excluded), only those in a deterministic sample bucket **"(SimDescriptionId % 100) < percent"** are processed; others are skipped **without** marking spread initialized.
- Reporting: **"SpreadSummary.SkippedNotInSample"**; report body may append **"Oniki.KinkyMod.OptionSettings.SpreadKinkyTraits:ReportSkippedSims"** with **"{0.Number}"** in STBL.
- **UI:** **"OptionSettingKinkyTraitsSpreadSamplePercent.cs"**; **".csproj"** compile include.
- **"UITools.LocalizeStringRaw(key)":** raw STBL **without** **"FillInTokens"** — fixes numeric lines stripped when **"Localize"** received **no** parameters ( **"{0.Number}"** discarded). Used from **"ResolveOneNumberLocalization"** in spread sample UI.
- Scope note text moved into **"OptionSettings.KinkyTraitsSpreadSamplePercent.EditPrompt"**; **"OptionSettings.SpreadKinkyTraits:ConfirmToolScopeNote"** no longer appended (remove from package if obsolete).

### Social discovery of kinky traits (LTR, cooldown, Observant)

- **"SimTools.TryDiscoverRandomUnknownKinkyTraitFromSocial(learner, about, probabilityMultiplier)"** + **"BuildUnknownKinkyTraitCandidatesForLearner"**: **"learner"** learns a random **unknown** kinky trait on **"about"** via **"TraitLearned"** (vanilla direction semantics).
- **Chance:** LTR liking normalized to **[0,1]**, then **"p = (pMin + (pMax - pMin) * norm) * multiplier"** with **"pMin = 0.05"**, **"pMax = 0.38"** (constants in **"SimTools"**).
- **Cooldown:** on success, **"SimData.AddCooldown("KinkyTraitSocialDiscovery", about, hours)"** on the **learner** with **"kKinkyTraitSocialDiscoveryCooldownHours = 6f"** (Sim hours).
- **Hooks:** **"SocialCallbacks.OnKinkyTalk"** — multiplier **"1.25"**; **"Seduce" / "Tease"** **"PostSocialLoop"** — multiplier **"1.0"** when interaction **not** **"Rejected"**.
- **Observant ("TraitNames.Observant"):** after a **first** successful learn, if the trait is present and candidates remain, a **second** random **"TraitLearned"** without a second probability roll (one cooldown event).
- **Guards:** **"CanUseKinkyTraits"** on both Sims; candidates from **"GetKinkyTraits"** / unified trait checks / **"KnownTraits"**.
- **Files:** **"Oniki.Utilities/SimTools.cs"**, **"Oniki.Gameplay/SocialCallbacks.cs"**, **"Oniki.Interactions/Seduce.cs"**, **"Oniki.Interactions/Tease.cs"**.

### "KinkyTraits" **XML** ("_XML" resource) — "<Predicate>" + typos

- **Deploy:** reimport modified **"KinkyTraits"** "_XML" into the game package (same instance) or runtime keeps old tuning.
- **Added "<Predicate>"** (fixes empty slot in vanilla **“learned trait”** TNS driven by **"Gameplay/Socializing/InformationLearnedAboutSim:LearnTraitText"** → **"Gameplay/Excel/traits/TraitList:<Predicate>"**) for traits including: **"LoveBigBoobs"**, **"LoveBigButt"**, **"LoveHairy"**, **"LoveBigCock"**, **"LoveMuscular"**, **"LoveTattoo"**, **"LoveFutanari"**, **"LoveCurvy"**, **"LoveBaldPussy"**, **"LoveElders"**, **"LoveTeens"**, **"DaughterLover"**, **"LoveFeet"** — predicate tokens e.g. **"IsLoveBigBoobs"**, …, full key string **"Gameplay/Excel/traits/TraitList:<Predicate>"** for STBL hashing.
- **"Junky":** predicate **"NeverUsed"** → **"IsJunky"**.
- **"LoveBigCock":** description/tooltip token typos corrected (**"LoveBigCockDescription"**, **"LoveBigCockToolTipText0"** vs legacy **"LoveBick…" / "LoveBigBock…"**).

### Human kinky rewards — no **"RemoveWorstTraits"** before **"TraitEarned"**

- **Issue:** On very young teens with pending **"Traits.WooHooer"**, legacy path called **"RemoveWorstTraits"** on vanilla **"TraitManager"** then **"TraitEarned"** into KW state — could **evict** a vanilla Simology trait **without** the reward occupying that slot (hole in Simology tab + kinky reward elsewhere).
- **Fix:** removed that pairing for kinky/migratable rewards; **"TraitEarned" / "KinkyTraitsState"** still handle cap overflow / replace prompts.
- **"SimData.ActivePendingTrait"** — teen **"AgeScale < 0.5f"**: still **"RemoveConflictingTraits"**, debug notify, **"TraitEarned"**; **no** **"RemoveWorstTraits"**.
- **"BuffFirstWooHoo"** — young teen branch: **"TraitEarned(WooHooer)"** only if missing; aligned policy.
- **"StrayAnimals.MakeRapistDog"** unchanged (**EA pet** trait path still uses **"RemoveWorstTraits"**).

### STBL checklist (options — full "Oniki.KinkyMod.*" prefix)

- **"Oniki.KinkyMod.OptionSettings.MenuKinkyTraitsSettings.Label"**
- **"Oniki.KinkyMod.OptionSettings.DisableKinkyTraitsAutoPicker"**
- **"Oniki.KinkyMod.OptionSettings.KinkyTraitsSpreadSamplePercent"**, **"Oniki.KinkyMod.OptionSettings.KinkyTraitsSpreadSamplePercent.EditPrompt"**
- **"Oniki.KinkyMod.OptionSettings.SpreadKinkyTraits:ReportSkippedSims"** (use **"{0.Number}"** with parameter from code)

### Code touchpoints (index)

- **"Oniki/Settings.cs"**, **"Oniki.UI/Hud.cs"**, **"Oniki.UI/OptionSettingMenuGlobal.cs"**, **"Oniki.UI/OptionSettingKinkyTraitsSpreadSamplePercent.cs"**, **"Oniki.UI/OptionSettingKinkyTraitsSpread.cs"**, **"Oniki.Gameplay/KinkyTraitsState.cs"**, **"Oniki/UITools.cs"**, **"Oniki.Gameplay/SimData.cs"**, **"Oniki.Gameplay/BuffFirstWooHoo.cs"**, **"Oniki_KinkyMod.csproj"**.

---

## Global settings: "MoodletDesperateWife" ("Enable Desperate Wife") — effective behavior + story feedback aligned

- **Issue (before):** The persisted option "MoodletDesperateWife" appeared in the Global menu but was **never read** by gameplay: the "DesperateWife" moodlet/buff and related messages followed only "IsDesperateWife()" (partner + WooHoo motive, no gender distinction on the buff). Also "DesperateWifeFeedback" showed "StoryFeedback:DesperateWife1/2" **only if** the actor was female; the autonomy branch "StoryFeedback:DesperateWife3" depended on "IsFaithfulWife", which required "actor.IsFemale", so **male partners were excluded**. Elsewhere ("GetCheatingDifficulty", "WooHooSequence") notifications could still fire regardless of the toggle. Parameters passed to "Localize" included a third argument "GetPartnerName" (localized LTR label like boyfriend/husband), poorly suited if language is forced via mod.

- **Change:**
  - "Oniki.Gameplay.SimData" — at the start of "IsDesperateWife()": if "Main.Settings" is null or "GetBool("MoodletDesperateWife")" is false, return "false" (no buff from "Perform", "else" branch removes buff if present).
  - "Oniki.Utilities.WooHooTools" — in "DesperateWifeFeedback(SimData, SimRelation)": same guard on "MoodletDesperateWife" before notifications; removed the "IsFemale" gate on "DesperateWife1/2"; replaced "IsFaithfulWife" with **"IsFaithfulPartner"** (same WooHoo reputation / "cheater" logic, **without** requiring female gender).
  - "Oniki.Gameplay.Autonomy" — "DesperateWife3": notify only if "MoodletDesperateWife" is true; use "IsFaithfulPartner"; localization parameters reduced to **two** objects (actor and partner).
  - "WooHooTools.NameTokenForStoryFeedback(SimDescription)": for "{N.SimName}" in STBL, prefer "CreatedSim" when available, otherwise "SimDescription" (off-world partner); **removed** the third parameter "SimTools.GetPartnerName(...)" from "DesperateWife1/2/3".

- **Result:** The Global toggle controls the moodlet **and** all "StoryFeedback:DesperateWife1", "DesperateWife2", "DesperateWife3" messages; men and women receive the same notification logic where applicable; STBL can use "{0.SimName}" (Sim "in crisis") and "{1.SimName}" (partner) without "{2.String}" from LTR. Logical keys (prefix "Oniki.KinkyMod."): "StoryFeedback:DesperateWife1", "StoryFeedback:DesperateWife2", "StoryFeedback:DesperateWife3" — no new hashes in the DLL; STBL text updates handled by the language package.

---

## Dialogs & notifications: disable story notification "cheating WooHoo" ("feedbacks/cheating/woohoo")

- **Context:** The TNS tied to "Oniki.KinkyMod.feedbacks/cheating/woohoo" (cheating with Cheater trait) was **always** fired when gameplay conditions were true; it was not tied to "MoodletCheater" (which only controls application of the **"Cheater" buff**).
- **Change:** New persisted bool **"NotifyCheatingWooHooStory"** (default "true", same as legacy behavior). **Dialogs & Notifications** menu → entry labeled "OptionSettings.NotifyCheatingWooHooStory" (requires STBL). "WooHooInstance" notifies only if "Main.Settings.GetBool("NotifyCheatingWooHooStory")". Registered in "ResetSettings", "EnsureReleaseRequiredSettingsPresent", migration "PreviousVersion < 447" add-if-missing.
- **Note:** No extra "hidden" logic; explicit gate **only** on the story **notification**, independent of skill/trait/reputation which continue as before.

---

## Global settings: Morning Wood after sleep ("EnableMorningWood")

- **Context:** After waking, eligible male Sims could randomly receive the Morning Wood moodlet with outfit change / erection visibility ("SimData.AddMorningWood"), with no way to disable it from the menu.
- **Change:**
  - New persisted bool **"EnableMorningWood"**, default **"true"** (legacy behavior unchanged if save is not edited).
  - **"Oniki.Interactions/KWBedSleep.cs":** both calls to "AddMorningWood()" are gated by "Main.Settings.GetBool("EnableMorningWood")".
  - **"Oniki.Gameplay/SimData.cs":** at the start of "AddMorningWood()", same guard to cover possible future callsites beyond the bed.
  - **"Oniki/Settings.cs":** "ResetSettings", **"EnsureReleaseRequiredSettingsPresent()"** (add-if-missing), migration **"PreviousVersion < 447"** (add-if-missing).
  - **UI:** **Global Settings** menu, near other moodlet entries ("MoodletSexRejection", "MoodletCheater"), via "OptionSettingSimpleBool("EnableMorningWood")".
- **STBL:** menu label — key **"Oniki.KinkyMod.OptionSettings.EnableMorningWood"** (text handled by language package).
- **Result:** With toggle **OFF**, no Morning Wood nor outfit forced by that flow after sleep; other Sims' reactions tied to the broadcaster do not trigger if the moodlet is not applied.

---

## Sex gameplay mechanics: passive **LT** arousal over-time multiplier ("LTArousalOverTimeMultiplier")

- **Context:** Slow growth of **"mLTArousal"** on the Sim tick ("SimData.UpdateMotiveWooHoo") already used "Commodities.kArousalIncrease", traits, age, and global **"ArousalGainMultiplier"**; there was no **LT-only** lever separate from amplification that in some contexts also affects **STA** (e.g. WooHoo party).

- **Change:**
  - New persisted float **"LTArousalOverTimeMultiplier"**: default **"1"**, clamp range **"0"–"4"** (aligned with "Settings" + helper "EnsureLTArousalOverTimeMultiplierRange()" like other bounded floats). "ResetSettings", migration **"PreviousVersion < 447"** add-if-missing, **"EnsureReleaseRequiredSettingsPresent()"** for saves already at 447 without the key.
  - **"SimData.UpdateMotiveWooHoo":** on the branch that increments passive LT ("num4"), after "ArousalGainMultiplier", also multiply by "GetFloat("LTArousalOverTimeMultiplier")". Does not alter "ChangeSTArousal" paths nor STA-only erection morph.
  - **UI:** **Animations → Sex gameplay mechanics**, immediately below **Arousal Gain Multiplier** — "OptionSettingLTArousalOverTimeMultiplier": value column **"..."**, click → "StringInputDialog" with localized explanatory text + numeric field (current value in **InvariantCulture**), same pattern as **Post WooHoo arousal cooldown**.

- **STBL (full keys for hash work):**
  - "Oniki.KinkyMod.OptionSettings.LTArousalOverTimeMultiplier" — menu row label.
  - "Oniki.KinkyMod.OptionSettings.LTArousalOverTimeMultiplier.EditPrompt" — dialog body (range 0–4, default 1, effect only on passive LT; optional note that it does not by itself move STA/erection).

- **Result:** Tune baseline horniness over time without depending on social interactions; **1** = behavior equivalent to the previous product on this multiplier alone (given same "ArousalGainMultiplier" and rest).

---

## WooHoo skill **journal** ("WooHooSkill.TrackedStats") — partners, sex-work lines, active sperm, virginity timing

### How rows are assembled

- **"TrackedStats" getter:** after the base list from **"CreateSkillJournalInfo"** (**"mTrackedStats"** — per-type Teasing…Anal + **Reputation** only), append **in order**:
  1. **"AppendKwSexWorkJournalRowsIfApplicable"** (KW prostitution / sex-work lines, conditional),
  2. **"AppendPartnersJournalRows"**,
  3. **"AppendWombSpermJournalRows"** (**"Womb"**).
- **Whore title** and **Professional** (customers / earnings) are **not** baked into a one-shot cached journal list — they **recompute** on every **"TrackedStats"** read (same pattern as partners / womb), so the panel does not stay “frozen” from the first open.

### Sex-work journal (Whore title + Professional)

- **"WhoreTitle"** (**"Skills.WooHoo.WhoreTitle"** + value **"Skills.WooHoo.<Title>"**): emitted **only** if **"Title != WhoreTitles.Unknown"**.
- **"TrackedStatProfessional"** (**"Skills.WooHoo.Professional"**, "{0}" Customers, "{1}" Earnings): emitted **only** if **"GetStatTotalCount("Customers") > 0"** **or** **"GetStatTotalCount("Earnings") > 0"** (reputation alone does not force these rows).

### Per-category statistics (Teasing, Handjob, …)

- Code keys **"Skills.WooHoo.Stats.<WooHooTypes>"** → full STBL **"Oniki.KinkyMod.Skills.WooHoo.Stats.Teasing"**, **".Handjob"**, **".Oraljob"**, **".Vaginal"**, **".Anal"**, etc.
- **"TrackedStat" parameters (unchanged):** **"{0.Number}"** = **"GetStatUniqueCount"** (distinct partners for that category), **"{1.Number}"** = **"GetStatTotalCount"** (total events), **"{2.Number}"** = **"floor(GetMastering * 100)"** mastering **%**.

### Partners section

- **Title:** suffix **"PartnersSectionTitle"** → **"Oniki.KinkyMod.Skills.WooHoo.Journal.PartnersSectionTitle"**. Section shows when **"HasAnyPartnerJournalData()"** — first/last/virginity/most-frequent rows **or** non-empty **"mJournalPartnerWooHooCounts"**.
- **Named rows** (**"{0.SimName}"**): **"PartnerFirstAny"**, **"PartnerFirstVirginity"**, **"PartnerLast"**, **"PartnerMostFrequent"** (max sum of acts in the partner count map). **"PartnerUnknownSim"** if **"SimDescription.Find"** fails.
- **Numeric rows** (**"{0.Number}"** each): **"PartnerCountFemale"**, **"PartnerCountMale"**, **"PartnerCountFuta"**, **"PartnerCountRobot"**, **"PartnerCountAnimal"**, **"PartnerCountTotal"** — each emitted **only if count "> 0"** (no "…: 0" filler). Counts are **distinct partner IDs** in **"mJournalPartnerWooHooCounts"**; **Total** = dictionary **".Count"** (includes unclassified IDs).
- **Bucket classification** (**mutually exclusive**, order): **"SimTools.IsKwRobotFamily"** → robot; else **"!sd.IsHuman"** → animal; else human with **"SimData.Get" + "IsFutanari"** → futa (**aligned with "WooHooedWithShemale" in "ReportAction"**, not female/male); else **"IsFemale" / "IsMale"**. Edge: Sim in map but no bucket still counts toward **Total**; **"SimDescription"** invalid → no bucket but can remain in **Total**; futa mis-bucket if **"SimData"** missing **"IsFutanari"** (known read-only-from-description limit).
- **Runtime updates:** **"RecordPartnerJournalForCountedWooHoo(partner)"** on counted WooHoos (existing relationship validity). **Persistence:** **"ExportPartnerJournal" / "ImportPartnerJournal"**, travel **"MergePartnerJournalFromTravel"**, fields **"mJournalFirstPartnerAnyId"**, **"mJournalFirstVirginityPartnerId"**, **"mJournalLastPartnerId"**, **"mJournalPartnerWooHooCounts"**.

### **First virginity partner** row — fix vs **"ReportAction"** timing

- **Issue:** **"LostVirginityWith*"** flags are applied in **"WooHooTools.PostWooHoo_Effects"** (from **"WooHooInstance.EvaluateOutcomes()"**) **after** **"PostWooHoo()"**, while **"PostWooHoo()"** runs **"ReportAction"** earlier — so **"ReportAction"** could never see the physical-virginity transition for journal purposes.
- **Fix:** **"WooHooSkill.TryRecordJournalFirstVirginityPartner(SimDescription partner)"** — sets **"mJournalFirstVirginityPartnerId"** once (partner = actor who “took” virginity in KW flow: **"actorData.Sim"**), called **right after** **"AddFlags(LostVirginity…)"** in **"PostWooHoo_Effects"**, and from **"Womb.RemoveVirginitiesForNonKwPregnancy"** when virginity is cleared there. **"ReportAction"** may keep a rare fallback if flags were set in-call; normal path is **"PostWooHoo_Effects"**.
- **UX timing:** the line appears when the **WooHoo sequence finishes** (**"EvaluateOutcomes"**, not necessarily the first stage) — accepted project behavior. Applies to **male and female** physical virginity per existing KW branches.

### Womb section — active sperm donors

- **"Womb.GetActiveSpermDonorsForJournal()"** → **"ActiveSpermDonorJournalRow"** list (one row per **unique** donor, merged level/lifetime, sorted by level; only rows with positive merged level after merge).
- **STBL:** **"Oniki.KinkyMod.Skills.WooHoo.Journal.WombSpermSectionTitle"**; **"WombSpermDonorRow"** — **"{0.SimName}"**, **"{1.Number}"**, **"{2.Number}"** (level and lifetime as integers in UI). Block only if **"SimData.Womb"** exists and list non-empty.

### STBL checklist (full "Oniki.KinkyMod." prefix for localization workflow)

- **"Oniki.KinkyMod.Skills.WooHoo.Journal.PartnersSectionTitle"**, **"PartnerFirstAny"**, **"PartnerFirstVirginity"**, **"PartnerLast"**, **"PartnerMostFrequent"**, **"PartnerUnknownSim"**, **"PartnerCountFemale"**, **"PartnerCountMale"**, **"PartnerCountFuta"**, **"PartnerCountRobot"**, **"PartnerCountAnimal"**, **"PartnerCountTotal"** (numeric rows: **"{0.Number}"**).
- **"Oniki.KinkyMod.Skills.WooHoo.Stats.*"** per category (**"{0.Number}"**, **"{1.Number}"**, **"{2.Number}"**).
- **"Skills.WooHoo.WhoreTitle"** + tier title strings, **"Skills.WooHoo.Professional"**, **"WombSpermSectionTitle"**, **"WombSpermDonorRow"**.

### Code touchpoints (index)

- **"Oniki.Gameplay.Skills/WooHooSkill.cs"** — **"TrackedStats"**, **"AppendKwSexWorkJournalRowsIfApplicable"**, **"AppendPartnersJournalRows"**, partner journal helpers, import/export / travel merge.
- **"Oniki.Utilities/WooHooTools.cs"** — **"PostWooHoo_Effects"** (**"TryRecordJournalFirstVirginityPartner"** after **"AddFlags"**).
- **"Oniki.Gameplay/Womb.cs"** — **"GetActiveSpermDonorsForJournal"**, **"RemoveVirginitiesForNonKwPregnancy"** journal hook.
- **"Oniki.Gameplay/WooHooInstance.cs"** — sequence: **"PostWooHoo()"** then **"EvaluateOutcomes()"**.

### Note

- **Kraken** and other edge flows may still diverge virginity vs physical flags — legacy behavior, not fully solved by journal work alone.

---

## Shower / tub sex: **Hygiene** + **Mermaid** hydration ("MermaidDermalHydration") under running water

### Issue (before)

- During **shower masturbation**, **tease**, and **shower WooHoo** (object menu / join), water ran visually but **Hygiene** often did not rise like a “normal” shower; **mermaids** could miss **"MermaidDermalHydration"** on **"WooHooJoinInShower"** because the joiner never hit **"TakeShower"**’s vanilla cleanup path.
- **"KWWooHooFreezeMotiveDecay"** (default **true**) could **freeze Hygiene** while **not** freezing mermaid hydration → mismatched bars.
- Detecting **"Shower" / "Bathtub"** concrete types missed many CC fixtures that implement **"IShower" / "IBathtub"**.

### Running-water detection and rates

- **"IsRunningWaterPlumbingObject(GameObject)":** non-null object **and** (**"IShower"** **or** **"IBathtub"**).
- Shared fill rate: **3** motive units **per Sim minute** on **Hygiene** and **3** on **"MermaidDermalHydration"** when present, via **"SetValue"** clamped to cap — **"TakeShower.ApplyRunningShowerWaterMotives"** aligns with **"WooHooInstance"** constants (**"kRunningWater*"** / **"ApplyRunningWaterHygieneAndHydrationForSim"**).

### Freeze decay during WooHoo

- When **"Main.Settings.KWWooHooFreezeMotiveDecay"** is **true**, **do not** call **"FreezeDecay(Hygiene)"** if **"IsRunningWaterPlumbingObject(GameObject)"** — hygiene can rise in shower/tub alongside mermaid hydration. Bladder / hunger / (energy below threshold) behavior unchanged.

### WooHooInstance refill (both participants)

- Refill is **not** stuffed inside per-Sim **"UpdateMotives"**.
- After the **"foreach"** over **"mSims"** in **"WooHooInstance.Loop()"** → **"ApplyRunningWaterMotivesAfterMotivesTick()"** → **"ApplyRunningWaterHygieneAndHydrationForSim(sim, deltaHours)"** for each participant.
- **"deltaHours":** **"mRunningWaterDeltaOverride"** if set (**≥ 0**), else current iteration **"mLoopDuration"**. Override is **always consumed** after read (even off shower/tub) to avoid stale values on bed / other objects.

### DoLoop-style delta override (UI granularity)

- **"WooHooInstance.SetRunningWaterDeltaOverrideForNextLoop(float deltaSimHours)"** (**"deltaSimHours > 0"**) — call **before** **"Loop()"** from **"WooHooJoinInShower"**, **"WooHooJoinGeneric"**, **"SniffAndLickSimOnAllFours"** (**"LoopData.mDeltaTime"**); **"UseToilet"** glory-hole WooHoo branch only; **"WooHooLoop"** uses **SimClock** delta between master-loop iterations — motive UI may still update in **chunks** if the engine batches ticks between **"Loop()"** calls (accepted driver limit).

### Masturbation / tease in shower **without** "WooHooInstance"

- **"TakeShower":** **"DuringShower"** calls **"ApplyRunningShowerWaterMotives(loopData.mDeltaTime)"** only for **"Masturbate"** and **"Tease"** — **not** during **"WooHoo"** (avoids **double** refill with **"WooHooInstance"**).

### Out of scope (this wave)

- **"ShowerOutdoor_TakeShower"**, **"ShowerPublic_Dance_TakeShower"**, and similar Store/outdoor/venue showers — different pipelines; separate follow-up if aligned.

### Code touchpoints (index)

- **"Oniki.Interactions/TakeShower.cs"**, **"Oniki.Gameplay/WooHooInstance.cs"**, **"Oniki.Interactions/WooHooJoinInShower.cs"**, **"Oniki.Interactions/WooHooJoinGeneric.cs"**, **"Oniki.Interactions/SniffAndLickSimOnAllFours.cs"**, **"Oniki.Interactions/UseToilet.cs"**, **"Oniki.Interactions/WooHooLoop.cs"**.

---

## KW **underwear / lingerie** — CAS sync, diagnostics, exhibition bypass, WooHoo remount, robots, policy toggle

### Exhibition scoring vs **player** **"UndressTop" / "UndressBottom"**

- When **"Main.Settings.SelectableSimsAlwaysAcceptPlayerInputs"** is **ON** and the command is **not** autonomous, the **active** selectable Sim (**"Actor.IsSelectable"**, **"Actor.IsActiveSim"**) follows the same **accept** pattern as other KW exhibition interactions (**"ShowBottom"**, **"ShowPanties"**, **"Flash"**, **"Grope"**, …): **"accept = true"** with **"ExhibitionTools.GetExhibitionScoringParameters"** so outfit swap runs even at **high** autonomy level.
- **"Oniki.Interactions/UndressBottom.cs"**, **"UndressTop.cs"** — new branch after **"base.Autonomous || Definition.Accepted"**; top path keeps **"nakedFlags"** consistent with max-skill / bra behavior.

### CAS ↔ inventory diagnostics ("[KW-UNDERWEAR-DEBUG]")

- When **"EnableUnderwear"**, **"DebugInteractionLogging"**, and **"Log.IsGlobalBufferEnabled()"** (**"EnableGlobalBuffer"**) are on: **"Log.WriteInteraction"** lines for mismatch phases — e.g. **"UpdateNakedFlags.PantiesAbsentOnOutfit"** / **"BraAbsentOnOutfit"** (**"OutfitManager"**), **"RemovePantiesInternal"** / **"RemoveBraInternal"**, **"ShowPanties.BeforeRoute"** / **"AfterPeekOutfitSwap"** — exported on **"KWInteractionLog_"**.
- Helpers: **"ShouldLogUnderwearCasMismatchDiag"**, **"TryUnderwearCasMismatchDiagInteractionLog"**, **"DiagnosticTraceUnderwearFlow"**.

### Registered panties / bra — scan, **"CreateOutfit"**, **"CreateOutfitFromTask"**

- **Matching:** **"MatchesRegisteredPantiesKey"** / Bra **"MatchesRegisteredBraKey"**, **"PartMatchesRegisteredPanties"** / **"PartMatchesRegisteredBra"** (**"Key"** or **"ParentKey"** on struct **"CASPart"**), **"GetRegisteredPantiesScanInstanceId"** / **"GetRegisteredBraScanInstanceId"** for **"CASPProperties.Get"** and **"__GetNakedFlags"** / **"GetCASPartsNakedFlags"**.
- **"CreateOutfit":** stripping **LowerBody** / **UpperBody** / **FullBody** respects registered pieces; **re-merge** worn panties/bra from **"OutfitManager"** when **"EnableUnderwear"**, flags allow, and inventory objects are valid (**"AddPanties" / "AddBra"** use broad key match, not only **"Contains"**).
- **"CreateOutfitFromTask":** **"forceKwUnderwearLowerMerge"** / **"forceKwUnderwearUpperMerge"** bypass the **"nakedFlags == flags"** shortcut (and related cache) when the wardrobe scan shows **no** panty/bra IID but **"mPanties" / "mBra"** are valid (**upper** merge also when only **lower** strip dropped bra from **FullBody** — **"LowerNaked" or "UpperNaked"** as per implementation).
- **"UpdateNakedFlags":** log absent-on-outfit + part dump before **"SetPanties(null)" / "SetBra(null)"**.
- **Intentional:** **"ShowBoobs"** stays **"BraLess"** — bra is **not** forced back by this merge path.

### Wet panties buff without arousal

- **"Underwear.RemoveBuffs":** for **"Type == Panties"**, after removing buffs from the Sim, clear **"StateFlags.Wet"** on the object so **"OnWorn"** does not reapply **"WetPanties"** from stale wet state alone.

### **"SuspendPantiesCasMismatchDemount"** (peek / **Ask Show Bottom**)

- If **bra** is still present on the CAS scan (**"braScanId != 0"**), **do not** CAS-demount panties in that suspend path **regardless of "PantyLess"** — avoids cascading demount (panties → inventory, then bra) when lower peek sets **"PantyLess"** while bra mesh remains.

### WooHoo end — **GetDressed** chain + remount **after** dress (442-like)

- **"WooHooInstance.Stop":** removed primary restore via immediate **"ChangeOutfit(..., PreWooHooNakedFlags)"** + same-tick remount.
- If **"OutfitManager.CanDressUp()"**: queue **"GetDressed"** via **"InsertNextInteraction"**; **"kwUnderwearDressAssist"** when **"EnableUnderwear"**, **"Sim.IsFemale"**, **"!SimTools.IsKwRobotFamilyExcludedFromUnderwearDynamics"**, and pre-WooHoo panty/bra IID snapshot **≠ 0** — sets **"ForcePanties" / "ForceBra"** on **"GetDressed"**.
- On success: **"InteractionQueue.TryPushAsContinuation"** to **"WooHooPostGetDressedRemountUnderwear"** (**new** script-only immediate) which calls **"OutfitManager.TryRemountPreWooHooUnderwearFromInventory"** in **"Run()"** **after** **"GetDressed"** completes (not same stack as **"Stop"**).
- **"GetDressed.Run":** **"ForceBra"** + **"NakedFlags.ForceBra"** ( **"ForcePanties"** already existed ).
- Fallback if **"!CanDressUp":** **"ChangeClothes"** as before without this continuation chain.
- **Timing:** **"GetDressed"** is queued from **"Stop"** before WooHoo outcome solo animation runs; execution begins after exiting the WooHoo loop.
- **Files:** **"WooHooInstance.cs"**, **"GetDressed.cs"**, **"WooHooPostGetDressedRemountUnderwear.cs"**, **"Oniki_KinkyMod.csproj"**.

### Robot family — **no** KW underwear dynamics (EP11 Plumbot + Ambitions servobot, **not** droid)

- **"SimTools.IsKwRobotFamilyExcludedFromUnderwearDynamics(SimDescription)"** — **alias** of **"KwRobotLacksHumanNakednessPipeline"** (robot family, **not** **"SimData.IsDroid"**, **EP11 or Ambitions servobot**). **Droids** keep human-like underwear behavior.
- Gated: **"GetUnderwear"**, **"PutPanties" / "PutBra"**, **"Underwear.Wear"**, **"SmellKinkyUndies"**, **"GivePanties"**, **"AskToGivePanties"**; **"SimData.CreateUnderwears()"** filter; **"OutfitManager"** merge/wear/init/set (**non-null** set blocked); **"TryRemountPreWooHooUnderwearFromInventory"**; WooHoo dress-assist branch.
- **Social nuance:** some **Ask** flows **to human targets** may still exist for robots; **Show** as **actor** robot remains blocked by **"KwRobotLacksHumanNakednessPipeline"** where applicable.

### Settings: **"AlwaysAllowUnderwearGrabWearAnyOutfit"** (default **"false"**)

- **UI:** **"OptionSettingMenuUndies"** — below **Enable underwear**, via **"OptionSettingEnabledSimple"**.
- **"OutfitTools.ShouldBlockGrabWearForCategoryPolicy":** if **"AlwaysAllowUnderwearGrabWearAnyOutfit"** **ON**, **"GetUnderwear.Test"** and **"Underwear.Wear.CommonTest"** do **not** block on sleep/swim (etc.) policy from **"NoUnderwearIsAllowed"**; **OFF** = legacy strict category block for player **and** autonomy. **Hard fails** remain: **"Singed"**, **"SkinnyDippingTowel"**.
- **"GetUnderwear":** with toggle **ON**, skip the early **"FindBestUnderwear" / clean-inventory** path that **hid** pie options when the Sim already had clean undies (dresser looked empty).
- **"Settings":** **"PreviousVersion < 447"** add-if-missing, **"EnsureReleaseRequiredSettingsPresent"**.
- **STBL:** **"Oniki.KinkyMod.OptionSettings.AlwaysAllowUnderwearGrabWearAnyOutfit"**.

### Autonomy cooldown **"putpanties" / "putbra"** after **Wear**

- **"Underwear.Wear" "RunInternal":** after successful **"WearPanties" / "WearBra"**, **"AddCooldown"** **"1f"** Sim hours (was **"6f"**) on the dressing Sim — read only from **"PutPanties" / "PutBra" "Test"** in the **autonomous** branch. **Wear** autonomy does **not** consult this cooldown.

### Code touchpoints (index)

- **"Oniki.Utilities/OutfitTools.cs"**, **"Oniki.Gameplay/OutfitManager.cs"**, **"Sims3.Gameplay.Objects.OnikiStuff/Underwear.cs"**, **"Oniki.Interactions/GetUnderwear.cs"**, **"Oniki.Interactions/UndressTop.cs"**, **"UndressBottom.cs"**, **"ShowPanties.cs"**, **"Oniki.Gameplay/WooHooInstance.cs"**, **"GetDressed.cs"**, **"WooHooPostGetDressedRemountUnderwear.cs"**, **"Oniki/Settings.cs"**, **"Oniki.UI/OptionSettingMenuUndies.cs"**, **"Oniki.Utilities/SimTools.cs"**, plus interactions listed under robot gate.

---

## Miscellaneous: "UseEACleanWithMop" — mop puddle without KW animation branch (lightweight toggle)

- **Context:** "Terrain_MopPuddle" replaces the vanilla interaction and, when KW is active and gender/traits/age/outfit checks on the lower body pass, runs the KW variant ("cleaningMop" / "MopOnAllFours"). In some installs this coincides with rig distortions; a **full** EA restore (Singleton/tuning/Test) is not required here: skip that branch when the user asks.

- **Change (code):**
  - New persisted bool **"UseEACleanWithMop"**, default **"false"** (legacy behavior unchanged if menu is untouched).
  - **"Oniki/Settings.cs":** property "UseEACleanWithMop"; "ResetSettings"; "EnsureReleaseRequiredSettingsPresent()" add-if-missing; migration **"PreviousVersion < 447"** add-if-missing ("false").
  - **"Oniki.UI/OptionSettingMenuMisc.cs":** **Miscellaneous** entry, immediately **after** "RestoreEAFunnyBucket", via "OptionSettingEnabledSimple("UseEACleanWithMop")" — state shown as **Enabled** / **Disabled** ("OptionSettings.Enabled" / "OptionSettings.Disabled"), not "True/False".
  - **"Oniki.Interactions/Terrain_MopPuddle.cs":** in "Run()", after guards on "SimData" / "OutfitManager", if "Main.Settings.UseEACleanWithMop" is **true** → **"return base.Run();"** (no entry into "cleaningMop" block); if **false**, flow unchanged from previous source.

- **Result:** With toggle **Enabled**, mop puddle uses only the base EA **"Run()"** for that instance; Singleton/replacement "Definition"/join WooHoo remain as before ("lightweight" option as agreed). Menu label: key **"OptionSettings.UseEACleanWithMop"** (STBL handled by language package; no hash in this changelog).

---

## Object: **Hole in the Wall** ("HoleInWall" / nested **"Inspect"**) — stuck loop, join chain, tunables, logging

**Scope:** KW scripted **"HoleInWall"** object ("Sims3.Gameplay.Objects.OnikiStuff/HoleInWall.cs") — **not** the vanilla toilet glory-hole / **"SmallHole"** path.

### Stuck loop — candidate pool and exit messaging

- When **"GetBestWooHooerOnLot"** returns **no Sim**, the stuck **"while"** now **"continue"s** instead of immediately clearing **"mIsStuck"** and showing a dialog (scene can recover on a later tick).
- **Single** closing feedback after the stuck loop ends: if **"mWooHooHistory.Count > 0"** → **"Oniki.KinkyMod.holeinwall/interactions/feedbacks:stuckwoohooend"**; otherwise → **"Oniki.KinkyMod.holeinwall/interactions/feedbacks:unstuck"**.

### Idle budget vs random segment duration (decoupled tunables)

- **"kMinimumMinutes" / "kMaximumMinutes"** (**"[Tunable]"** on **"HoleInWall"**) drive **only** **"RandomUtil.GetFloat"** for per-pass **"mDuration"** (InspectDeep segment length); **"kMaximumMinutes"** default restored to **"10f"** so segments stay in a **5–10** Sim-minute style range without tying that cap to idle accounting.
- **Idle “no active join” budget** uses a **separate** cap: tunable fallback **"kStuckIdleBudgetMaxSimMinutes"** (default **"60f"**) plus persisted **"HoleInWallStuckIdleBudgetMaxSimMinutes"** read through **"GetGameplayStuckIdleBudgetCapMinutes()"**; accumulation in **"StuckIdleBudgetAccumulateInLoopDeep()"** at **"LoopDeep" start** (after **"ApplyPendingJoinType"**); paused while a WooHooer or join/pending-join state is active. Exit when **"mStuckIdleBudgetMinutes >="** that cap.
- **Absolute failsafe:** **"kStuckAbsoluteSafetyMaxSimMinutes"** (default **"240f"**) plus persisted **"HoleInWallAbsoluteSafetyMaxSimMinutes"** via **"GetGameplayAbsoluteSafetyMaxSimMinutes()"** from **"mStuckAbsoluteStartTime"**.

### Settings + in-game **Hole In Wall** menu

- **"Oniki/Settings.cs":** **"EnsureHoleInWallSettingsPresent"**, segment order helper **"EnsureHoleInWallInspectDeepSegmentOrder()"**, **"Upgrade"** add-if-missing for HIW keys under **"PreviousVersion < 447"**, **"ResetSettings"** defaults.
- **Numeric:** **"HoleInWallInspectDeepSegmentMinMinutes"** / **"HoleInWallInspectDeepSegmentMaxMinutes"** (defaults **5** / **10**; **"GetGameplayInspectDeepSegmentRange"** clamps vs tunables). **"HoleInWallStuckIdleBudgetMaxSimMinutes"**, **"HoleInWallAbsoluteSafetyMaxSimMinutes"** ("OptionSettingMenuHoleInWall.cs").
- **Bools:** **"HoleInWallJoinerAlwaysAccepts"**, **"HoleInWallIgnoreExhibitionForParticipants"**, **"HoleInWallRejectionDoesNotUnstuck"**, **"HoleInWallInspectDeepAlwaysStuck"** (Inspect deeper: **"mIsStuck"** forced **true** when ON, in addition to weighted random + **"kChanceToBeStuck"** — **global** for all KW **"HoleInWall"** instances). New **"HoleInWallDisallowAutonomousHelp"** (default **"false"**): **"Help.Definition.Test"** returns **false** for **autonomous** helpers when ON; player-driven Help unchanged if other tests pass.
- **STBL (menu keys + hashes where known):**
  - **"Oniki.KinkyMod.OptionSettings.HoleInWallInspectDeepAlwaysStuck"** — pattern as other "OptionSettings.*" (package owner adds hash).
  - **"Oniki.KinkyMod.OptionSettings.HoleInWallDisallowAutonomousHelp"** — **"0x95B3262F9797A224"**.

### Stuck **"while" — "DoLoop" exit-reason hygiene

- Before each stuck **"DoLoop"**, broad **"RemoveExitReason(...)"** cleanup so each segment starts clean.
- After **"DoLoop"**, if not a real user/script cancel, strip **"Canceled | Finished | StageComplete"** so the engine does not treat the Sim as “canceled” between stuck segments.

### InspectDeep segment rollover (still stuck)

- Flag **"mInspectDeepStuckSegmentExpired"**: when **"mIsStuck"** and the segment timer hits **"mDuration"**, set flag + log (segment cap **≠** unstuck). After **"DoLoop"**, rollover path clears staged exit reasons and logs rollover **without** **"Unstuck"**. Resets at each stuck iteration start. **"mDuration"** remains a **per-segment** cap; idle budget + absolute failsafe still end stuck when applicable.

### Lost pending join in queue (no fake user-cancel)

- If **"mPendingInteraction"** leaves the joiner’s queue during stuck: clear pointer + **"LoopDeepPendingJoinLost"** with **"ContinueStuckLoop=true"** — **no** **"AddExitReason(Canceled)"** (avoids bitmask **"ExitReason.Canceled"** being misread as unstuck). **Unchanged:** active join link loss (**"LoopDeepWooHooerLinkLost"**) still cancels when the partner is no longer on the linked interaction.

### Join generic — pre-apply failure hardening + telemetry

- **"Inspect"** tracks pending join via **"mPendingJoinRequesterId"** / **"mPendingJoinRequestedType"** with **"MarkPendingJoinRequested"**, **"ClearPendingJoinRequestedMarker"**, **"OnJoinCleanupFromJoiner(joiner, stopRequest)"**.
- **"WooHooJoinGeneric.Cleanup"** notifies the wall **"Inspect"** ("HoleInWall.cs" + **"Oniki.Interactions/WooHooJoinGeneric.cs"**).
- If state was **"PendingJoinRequested"** but join never applied (**"mJoinType == 0"** at cleanup): local cleanup, manual cooldown **"woohoojoingeneric"** (**"4f"**) on the joiner, log **"PendingJoinFailedBeforeApply"** (conservative path-only; does not alter healthy **"PendingJoinApplied"** flows).
- Extra structured logs: e.g. **"PendingJoinLoopTick"**, **"JoinRunAbort"** with enumerated reasons for early **"Run()"** **false** returns (**"TargetCurrentNotJoinable"**, route/register failures, etc.).

### Session XML log (optional)

- **"LogHoleInWallSessions"** (default **"false"**): **Misc → Logging Features** (after **"FirstPersonLogging"**). Buffer + quit dump in **"Oniki.Utilities/HoleInWallSessionLog.cs"** (prefix **"KWHoleInWallSessions_"**, **"DumpFormatVersion="1""**); **"KinkyMod"** world-quit handler dumps or clears. Session IDs are monotonic **"ulong"** (project constraint vs **"Guid"**). Instrumentation in **"HoleInWall"** / **"Help"** with snapshot helper and **"mHoleInWallSessionLogId"** correlation.
- **STBL:** **"Oniki.KinkyMod.OptionSettings.LogHoleInWallSessions"** — **"0x131F72667BB0EF11"**.

### Code touchpoints (index)

- **"Sims3.Gameplay.Objects.OnikiStuff/HoleInWall.cs"** — core stuck/join/rollover/logging.
- **"Oniki.Interactions/WooHooJoinGeneric.cs"** — cleanup callback, **"JoinRunAbort"** diagnostics.
- **"Oniki.Utilities/HoleInWallSessionLog.cs"**, **"Oniki/Settings.cs"**, **"Oniki/KinkyMod.cs"**, **"Oniki.UI/OptionSettingMenuHoleInWall.cs"**, **"Oniki.UI/OptionSettingMenuMisc.cs"**, **"Oniki_KinkyMod.csproj"**.

---

## Diseases: KW purge helpers + hospital “complete treatment” rabbit hole

### Purge API (KW diseases only)

- **"Disease.ForcePurge(Sim buffTarget)"** removes buffs from tuning ("Data.Buffs", excluding "Undefined"), optional **"Data.Moodlets"** when present in XML (supported for forward-compatible packages even if extracted reference XML had no "<Moodlets>" blocks), plus deterministic satellite buffs for **AIDS** ("Germy", "Nauseous", "Backache") and **Herpes** ("Itchy", "ItchingToScratch"). Resets internal treatment state and sets **"Stage = Invalid"** on the disease instance before list cleanup.
- **"SimData.HasAnyDisease()"** — "true" when the Sim has at least one KW disease entry.
- **"SimData.PurgeAllKwDiseases(Sim)"** — snapshots the list, runs **"ForcePurge"** per entry, then **"Remove"** from the Sim’s disease list **without** forcing vanilla undead state off (EA **"IsZombie"** / zombie layers stay engine-owned).

### Hospital rabbit hole: "Hospital_CompleteDiseaseTreatment"

- **New** player-only interaction on **"Hospital"** (same injectable pattern as other KW hospital entries). **"Test"** / **"InRabbitHole"** require **"FamilyFunds >="** the live cost read from settings (insufficient funds → grayed pie entry; same overall pattern as e.g. **"Hospital_Rehab"**).
- **Cost setting:** persisted int **"HospitalCompleteDiseaseTreatmentCost"** — default **"5000"**, **"SettingValue<int>"** clamp **"1"–"10000"** on assignment/UI; wired in **"ResetSettings"**, **"EnsureReleaseRequiredSettingsPresent"**, and **"Upgrade"** with add-if-missing. Minor edge: raw **"Import"** may temporarily hold an out-of-range value until the next edit/save applies the clamp.
- **UI:** **Settings → Diseases**, last row **"OptionSettingSimpleValue<int>("HospitalCompleteDiseaseTreatmentCost")"** — **"StringInputDialog"** like other numeric disease rows; value column shows the integer.
- **Pie menu title:** **"UITools.Localize("Hospital_CompleteDiseaseTreatment", new object[] { cost })"** so STBL can use **"{0.Money}"** (EA-style) or **"{0.Number}"** (and §) per **"FillInTokens"** in **"UITools"**.
- **Rabbit hole duration:** **"TimedStage"** duration argument is **Sim minutes** ("TimeUnit.Minutes"), not hours. Default **5 Sim hours** is expressed as **"kSimHoursInRabbitHole * 60f"** (300 minutes). A prior bug passed **"5f"**, which behaved as ~**5 Sim minutes**.
- **Success path:** debit household funds → **"SimData.PurgeAllKwDiseases(Actor)"** → localized completion notification. **No** daily cooldown (repeat whenever the Sim has funds and at least one KW disease after re-infection, if any).
- **Code touchpoints:** "Oniki.Gameplay/Disease.cs" ("ForcePurge", "RemoveSatelliteDiseaseBuffs"), "Oniki.Gameplay/SimData.cs" ("HasAnyDisease", "PurgeAllKwDiseases", "PurgeAllDiseaseInstances", related helpers), "Oniki.Interactions/Hospital_CompleteDiseaseTreatment.cs", "Oniki.UI/OptionSettingMenuDiseases.cs", "Oniki/Settings.cs", **"Oniki_KinkyMod.csproj"** compile include. Prefer **UTF-8** for "Hospital_CompleteDiseaseTreatment.cs" after Windows edits (project build/encoding workflow).

### STBL (full keys for hash work)

- **"Oniki.KinkyMod.Hospital_CompleteDiseaseTreatment"** — interaction name (**requires** cost parameter in code for **"{0.*}"** tokens).
- **"Oniki.KinkyMod.Hospital_CompleteDiseaseTreatment:NoActiveDiseases"** — tooltip when there is no active KW disease.
- **"Oniki.KinkyMod.Hospital_CompleteDiseaseTreatment:NotEnoughMoney"** — insufficient funds tooltip.
- **"Oniki.KinkyMod.Hospital_CompleteDiseaseTreatment:TreatmentComplete"** — post-treatment notification.
- **"Oniki.KinkyMod.OptionSettings.HospitalCompleteDiseaseTreatmentCost"** — Diseases menu row label.

### Note (legacy vs new)

- Existing **pharmacy shopping** remains **"Hospital_BuyMedicine"** → **"Store.BrowseStore("HospitalShopping")"**. Complete treatment is a **separate** one-shot rabbit-hole fee plus full KW disease purge, not that store browse flow.

---

## SimBots / Plumbots: **"AllowKinkySimBots"** parity — age gates, outfits, nudity, diagnostics

**Setting (existing):** persisted bool **"AllowKinkySimBots"** — menu label uses full STBL key **"Oniki.KinkyMod.OptionSettings.AllowKinkySimBots"** (same "UITools" / "KinkyMod.sNamespace" pipeline as other options).

### Debug: Show Infos Kinky diagnostic dump

- **"ShowInfosKinkyDiagnosticDump"** (toggle in settings when wired): XML export **"KWShowInfosKinkyDiag_*"** with **"DumpFormatVersion" 3** — extra fields for **EA age bands vs KW underage / chip flags** (e.g. "EA_CASAge", age booleans, "EA_IsFrankenstein", "Desc_TraitChipManager_NonNull", trait chip id probe). Root policy snapshot includes **"Setting_Teens"** via **"Main.Settings.GetBool("Teens")"**.
- Chips are read from **"SimDescription"** only (reference build: live **"Sim"** has no **"TraitChipManager"** accessor). **"Sim_ShowInfos"** cleanup calls **"ShowInfosKinkyDiagnosticDump.TryWrite"**. Prefer **UTF-8** on **"ShowInfosKinkyDiagnosticDump.cs"** after Windows edits.

### Age / WooHoo: Frankenstein + robot with parity ON

- **"SimTools.IsKwRobotFrankensteinEligibleForKinkyParity(SimDescription)"** (private): **"AllowKinkySimBots"** ON, **"IsKwRobotFamily"** + **"IsFrankenstein"**, and **Young Adult+**, or (**Teen** with **"Teens"** ON).
- **"IsUnderAge":** Frankenstein/servobot exclusion applies **only** when this helper is **false** (fixes adult SimBots also flagged as Frankenstein being treated as minors).
- **"CanWooHoo":** **"OccultTypes.Frankenstein"** branch does **not** hard-block when the helper is **true** (ordering-safe).

### WooHoo outfit pipeline: EP11 Plumbot vs Ambitions servobot

- **EP11 Plumbots** ("IsKwEP11Plumbot"): skip human **"OutfitManager"** / **"NakedFlags"** / **"SwitchOutfitHelper"** paths that merge human CAS legs onto the bot rig (WooHoo setup in **"WooHooInstance"**, **"MakeJoinSimReady" / "MakeSimReady"**, **"WooHooTools.MakeSimReady"**).
- **Parity ON — unified robot bypass:** **"IsKwRobotParityWooHooOutfitBypass"** ("IsKwRobotFamily && !IsServobotExcludedFromKinkyParity") gives EP11 and in-parity **Ambitions servobots** the **same** skip of the human naked merge as Plumbots.
- **"TryAddBotPenisForParityAmbitionsServobotWooHoo":** **non-EP11** servobots in parity only; male or **strapon-required** slot → **"OutfitTools.AddBotPenis"**. Eligibility uses **"KwAmbitionsServobotOutfitEligibleForBotPenis"** (not only **"OutfitHasSupernaturalPart"** on lower body).
- **Parity OFF** servobot branches that still add bot penis: same **"KwAmbitionsServobotOutfitEligibleForBotPenis"** check (aligned with parity ON).
- **EA vs KW “human”:** prepare/cleanup paths that gated on **"Sim.IsHuman" / "CreatedSim.IsHuman"** now use **"SimTools.IsHuman(SimDescription)"** / consistent overloads where servobots must reach bot-penis and teardown (**"WooHooInstance"** setup, join/master **"MakeSimReady"**, end-of-instance outfit cleanup; **"WooHooTools.MakeSimReady"**). End-of-sequence bot-penis / whip removal for **non-"IsDroid"** robots is expanded under **EP11 Plumbots** below (servobot + EP11 matrix).

### Outfit eligibility: **"KwAmbitionsServobotOutfitEligibleForBotPenis"**

- **"true"** if lower body passes **"OutfitHasSupernaturalPart"**, **or** (**"IsKwAmbitionsStyleServobot"**, not **"SimData.IsDroid"**, **"actor.IsRobot"**). **Droids** without supernatural lower-body flags stay **"false"** (no forced bot CAS on intentionally more-human morphs).

### Servobot underage (parity OFF)

- Servobots **excluded** from parity **no longer** require an **EP11 trait chip** in the underage gate (chips do not exist on generic Ambitions servobots).

### Naked broadcaster / intimate Ask & Show (parity-aligned robots)

- **"SimTools.KwRobotLacksHumanNakednessPipeline(SimDescription)"**: robot family, **not** **"SimData.IsDroid"**, and (**"IsKwEP11Plumbot"** **or** **"IsKwAmbitionsStyleServobot"**) — bots that should not run the human “felt naked” / reactive stack (droids keep human-intended pipeline).
- **"SimData.StartNakedBroadcaster":** if **"KwRobotLacksHumanNakednessPipeline"** → stop broadcaster, early return (includes EP11 non-droid even with Sex Bot chip).
- **"SimTools.IsAshamedToBeNaked"** (relevant overloads): **"false"** for that helper (**TraitNeverNude** / exposure pressure toned down).
- **Ask (target):** **"Test" false** when target matches **"KwRobotLacksHumanNakednessPipeline"** in **"AskToShowBoobs"**, **"AskToShowBra"**, **"AskToShowPanties"**, **"AskToShowBottom"**, **"AskToShowFeet"**; **"AskToGivePanties"** uses the same gate (replacing EP11-only bot check).
- **Show (actor):** after **"IsEitherParticipantRobot"**, if **"KwRobotLacksHumanNakednessPipeline(actor)"** → **"Test" false** for **"ShowBoobs"**, **"ShowBra"**, **"ShowPanties"**, **"ShowBottom"**, **"ShowFeet"** (blocks human CAS pulls from active bot actor).

### WooHoo when **"ShouldChangeOutfits == false"** + standalone masturbation visuals

- **"WooHooInstance.MakeSimReady":** when outfits are **not** changed, an **extra block** runs **servobot-only** parity paths (excluded servobot **"AddBotPenis"** vs **"TryAddBotPenisForParityAmbitionsServobotWooHoo"**) so joins like shower / glory hole / sniff-on-all-fours still get bot anatomy where applicable; marks entry ready + outfit snapshot to avoid repeat work.
- **"SimTools.MakeSimReadyForStandaloneMasturbationVisualParity(Sim)":** builds **"NakedFlags"** (strapon for **F** when strapon setting > Disabled) and calls **"WooHooTools.MakeSimReady"** — used from **"UseToilet"** (**"goMasturbate"** / **"StartTeasing"**), **"TakeShower"** (**"StartMasturbationInternal"**), **"WooHooMasturbatingSimOnAllFours"** (after strapon outfit). *Note:* the helper may still drive human **"OutfitManager"** paths for flesh Sims per **"WooHooClothing"**; only the **"!ShouldChangeOutfits"** block in **"WooHooInstance"** is effectively servobot-specific.

### Code touchpoints (index)

- **"Oniki.Utilities/SimTools.cs"** — parity helpers, bot penis eligibility, standalone masturbation prep, ashamed/naked pipeline, optional **"DiagnosticIsNPCRoommate"** for dump.
- **"Oniki.Gameplay/SimData.cs"** — **"StartNakedBroadcaster"**.
- **"Oniki.Gameplay/WooHooInstance.cs"**, **"Oniki.Utilities/WooHooTools.cs"**, **"Oniki.Utilities/OutfitTools.cs"** — **"AddBotPenis"** / servobot allowance.
- **"Oniki.Interactions/Debug/ShowInfosKinkyDiagnosticDump.cs"**, **"Sim_ShowInfos.cs"**.
- Ask/Show: **"AskToShow*.cs"**, **"Show*.cs"** as listed in the project TODO trace.

---

## EP11 Plumbots: **Sex Bot** chip unlock, **Womb** / **"PregnancyPlumbot"** scope, servobot “deposit-only”, EP11 **bot-penis** sync

**Companion:** outfit bypass, **"AllowKinkySimBots"**, **"KwRobotLacksHumanNakednessPipeline"**, and related Ask/Show gates are summarized under **SimBots / Plumbots** above; this block covers **trait-chip learnability**, **fertility model split** (EP11 vs Ambitions servobot), and **Plumbot-specific** WooHoo anatomy sync / teardown.

### Sex Bot trait chip — deterministic schematic unlock (Bot Building)

- **Issue:** "KinkyTraitChips" **"LevelToCreate"** is only a **pool admission** hint; vanilla **"DesignTraitChips"** still uses **RNG**, so the custom **Sex Bot** chip ("2943304806") rarely surfaces even at high Bot Building — parallel outlets (e.g. Oasis consignment) stay unchanged.
- **Change:** When Bot Building **≥** the XML **"LevelToCreate"** for that chip (from loaded **"TraitChipStaticData"** / **"TraitChips.Load"**), **"TryAutoUnlockSexBotSchematicIfEligible(SimDescription)"** idempotently adds the **Sex Bot** recipe to learned schematics (**"OnLearnedTraitChipSchematic"**) — no RNG. Gates: **"Main.Settings.Enabled"**, **"GameUtils.IsInstalled(ProductVersion.EP11)"**, chip data present.
- **Hooks:** **"Main"** — **"EventTypeId.kSkillLevelUp"** listener (**"SkillNames.BotBuilding"** only, **"HasGuidEvent<SkillNames>"**, **"Skill.SkillOwner"**); **"OnSimInstantiated"** for Sims already above threshold on load; **"SimData.ImportContent"** after restoring **"mKnownKWTraitChips"** to align saves.
- **Files:** **"Oniki.Gameplay/TraitChips.cs"**, **"Oniki/Main.cs"**, **"Oniki.Gameplay/SimData.cs"**.

### **"Womb"** for EP11 female **without** "IsDroid" — **Sex Bot** chip

- Female **EP11 Plumbots** that are **not** **"SimData.IsDroid"** could skip **"Womb"** creation because only the droid branch opened the path.
- **"SimData.KwSexBotChipUnlocksRobotWombPath()"**: if **"IsRobot"** and **not** droid, **Sex Bot** chip on **"TraitChipManager"** (**"TraitChips.SexBot"**) unlocks the same womb/update/morph/pill reminder pipeline as the existing “hardware” path; **"StartNakedBroadcaster"** stays consistent (robots with chip or in-scope servobots are not cut off by chip-only nakedness gate).

### **"PregnancyPlumbot"** — **EP11-only** conception; servobot **never** mothers

- **"PregnancyPlumbot"** **no longer** drives fertility for **Ambitions-style servobots** (**"IsKwAmbitionsStyleServobot"**); EP11 and servobot are separated in **"Womb"**.
- **EP11 Plumbot mother** (**"IsKwEP11Plumbot"**): conception only if **"PregnancyPlumbot"** is **ON** (plus **"EnableKWPregnancy"**, tubal ligation, etc.).
- **Ambitions servobot mother:** **never** meaningful **"StartPregnancy"** / **"PendingPregnancy"**; any pregnancy is cleared in the dedicated loop.
- **Male EP11 Plumbot donor:** excluded from **"UpdateOvulatory"** lottery when **"PregnancyPlumbot"** **OFF** (father-only rule, EP11-scoped).
- **Male Ambitions servobot donor:** **always** excluded from fertility lottery (**no** “robot father” roll); **"AddSperm"**, creampie, buffs can still apply.
- **File:** **"Oniki.Gameplay/Womb.cs"**.

### Ambitions servobot — **deposit-only** **"Womb"** when **"AllowKinkySimBots"**

- **"IsKwServobotAmbitionsDepositOnlyWomb()"** (private in **"Womb"**): **"IsKwAmbitionsStyleServobot"** **and** **"IsKwServobotKinkyScopeEnabled"** (servobot **and** toggle ON).
- **"InitializeCycle":** stable luteal-like phase + long timer; clears menstrual/fertile baseline buffs.
- **"Update":** short path — **"UpdateSperm"**, drop pregnancy if any, **"mPendingPregnancy = null"**, strip fertile markers — **no** follicular/ovulatory progression.
- **"AddSperm":** deposit-only **does not** require **"EnableFemaleFertility"** / **"EnableMenstrualCycleProgression"** so creampie works without global cycle ON.
- **"LoadFixup":** normalizes phase/map for inconsistent saves.
- **"SimData":** with toggle ON, female servobot gets **Womb** like Plumbot-with-chip path; toggle OFF — female **Womb** removed unless droid / EP11 chip path applies; teen+ morphs for in-scope servobots on **"UpdateSizes" / age / "Perform"**.
- **Files:** **"Oniki.Gameplay/Womb.cs"**, **"Oniki.Gameplay/SimData.cs"**.

### Robot taxonomy helpers (**"SimTools"**)

- Explicit splits: **"IsKwAmbitionsStyleServobot"** (KW robot, **not** EP11 Plumbot), **"IsKwServobotKinkyScopeEnabled"** (servobot **and** **"AllowKinkySimBots"**), **"IsKwRobotEp11Equivalent"**, refactors of **"IsHuman"**, **"IsServobotExcludedFromKinkyParity"**, **"TryAddBotPenisForParityAmbitionsServobotWooHoo"** guards — **legacy behavior preserved** where applicable.

### WooHoo — **EP11 Plumbot** bot penis (swap / stage flags) + teardown

- **Issue:** **"TryAddBotPenisForParityAmbitionsServobotWooHoo"** returns immediately for **non-**servobot Ambitions, so **EP11 Plumbots** never received **"AddBotPenis"** in parity bypass paths; **female** Plumbots after **position swap** (strapon “male” role) could lack bot penis; end-of-sequence cleanup removed **"kBotPenis"** only for **servobots**, leaving **EP11** with a stuck CAS part.
- **Change:** in **"SimTools"**, **"SyncKwEp11PlumbotBotPenisState"** (private), **"SyncKwEp11PlumbotBotPenisForWooHoo(actor, simData, stage, participantSlotId, straponUse)"**, **"SyncKwEp11PlumbotBotPenisForWooHooNaked(..., straponRequiredByFlags)"** — “needs penis” when **"ActorIsMaleForKwWooHoo"** **or** (strapon setting **not** **"Disabled"** **and** (**"straponUse"** **or** **"Strapon"** bit in **"stage.GetNakedFlags(simData, slot)"**)); **"straponUse"** matches **"SetupOutfit"** (**"AllowStrapon && stage.UseStrapon(...)"**). Covers tuning that sets strapon via **"NakedFlags"** without a matching **"UseStrapon"** flag on the participant.
- **"WooHooInstance":** after **"TryAddBotPenisForParityAmbitionsServobotWooHoo"** on the bypass branch, calls EP11 sync with the **correct slot** in **"SimEntry.SetupOutfit"** (including post-**"SwapActorsInternal"**), **"MakeJoinSimReady"**, **"MakeSimReady"** (both **"ShouldChangeOutfits"** and **"!ShouldChangeOutfits"** branches).
- **"WooHooTools.MakeSimReady":** bypass + **"SyncKwEp11PlumbotBotPenisForWooHooNaked"** with **"requiredStrapon"** from **"NakedFlags"**.
- **Servobot parity-OFF branch:** **"stage.GetStrapon(Id)"** of the entry field replaced with **"stage.GetStrapon(id)"** (**current method slot**) to fix rare misalignment.
- **Teardown:** participant cleanup removes bot penis / whip when **"!simData2.IsDroid"** **and** (**"IsKwAmbitionsStyleServobot(simData2.Sim)"** **or** **"IsKwEP11Plumbot(simData2.Sim)"**) — **"SimData.Sim"** is **"SimDescription"**.

### Regression checklist (in-game)

- EP11 female + Sex Bot chip, not droid → **Womb** + pregnancy only if **"PregnancyPlumbot"** ON. EP11 male donor obeys same toggle in lottery; **male servobot** never fertilizes via lottery. Female servobot + **"AllowKinkySimBots"** → deposit-only **Womb**; toggle OFF → **Womb** dropped when applicable. WooHoo: EP11 swap/strapon stages show bot penis when needed; **no** leftover penis after sequence.

### Code touchpoints (index)

- **"Oniki.Gameplay/TraitChips.cs"**, **"Oniki/Main.cs"**, **"Oniki.Gameplay/SimData.cs"**, **"Oniki.Gameplay/Womb.cs"**, **"Oniki.Utilities/SimTools.cs"**, **"Oniki.Gameplay/WooHooInstance.cs"**, **"Oniki.Utilities/WooHooTools.cs"**.

---

## LoversLab / services: **KW Mail Carrier** — mailbox safety, default off, runtime toggle, gender

### Issue (before)

- **"KWMailCarrier" / UI vs service:** "Stop()" tore down **only** when both "Enabled && KWMailCarrier" were true, so the service was **not** destroyed when the option was turned off (or after uninstall with "Enabled = false"). The menu toggle did not refresh the "MailCarrier" singleton.
- **"ResetSettings" vs migration "PreviousVersion < 410":** inconsistent defaults (reset to "true", migration to "false"); confusing for new save vs migrated save.
- **Custom mail delivery ("KWPutMail"):** interaction derived from "Mailbox.PutMail" with "CreateObjectOutOfWorld" / "TransferToVisibleMail"; risk of corrupted mailbox state and soft reset on mail pickup.
- **Mail carrier gender:** "CreateNewNPCForPool" used "(setting == Female) ? Female : Male". With enum "Genders", **"Both" is "0"**, so it was treated as **male** like "Male". Also the service constructor migrated into the pool **only** "IsMale" descriptions, dropping female carriers already in pool when switching to female gender.

### Change (code)

- **"Oniki/Settings.cs":** in "ResetSettings", **"KWMailCarrier" default "false"** (aligned with "<410" gate; same persisted key).
- **"Oniki.Services/KWMailCarrier.cs":**
  - **"ApplyRuntimeToggle()":** if mod enabled and option on → "Start()", else → **"Stop()"** which now **always** removes the KW instance if present and clears "MailCarrier.sDeliverer" when it matches (same teardown style as e.g. pizza).
  - Constructor: pool migration from "IsMale"-only → **all** non-null "SimDescription" from the previous instance.
  - **Gender:** "Oniki.Utilities.ServiceNpcGender.CreateGenderFlags(Main.Settings.KWMailCarrierGender)" in **"Oniki.Utilities/GameTools.cs"** — "Male" / "Female" / **"Both"** ("RandomUtil.RandomChance01(0.5f)").
- **"Oniki.UI/OptionSettingMailCarrier.cs":** after bool change → **"KWMailCarrier.ApplyRuntimeToggle()"**.
- **"Oniki.UI/OptionSettingMailCarrierGender.cs":** after enum change → **"KWMailCarrier.ApplyRuntimeToggle()"** (recreates service if KW mail carrier is still required so new gender affects next NPC pool).
- **"Oniki.Situations/KWMailCarrierSituation.cs":** removed entire **"KWPutMail"**; **"KWRouteToLot"** after "OnServiceStarting()" goes straight to **"KWHangAroundBeforeLeaving"** (dialogue / social / lot stay **without** touching mailbox). Removed unused "using"s.

### Result / gameplay implications

- No physical delivery to mailbox from the **KW** flow on that visit; bills/email may still be handled by **other** EA systems (internal game routing outside the carrier situation) — verify in-game for your expansion/world combo.
- **"Vanilla" mail from a second parallel EA mail carrier:** **no.** The game has a single entry point for mail service ("MailCarrier.sDeliverer"). With KW mail carrier ON, that role is the KW instance; there is no duplicate "vanilla only" on the same service slot.

### Service NPCs: gender "Genders.Both" — **Pizza** and **Repairman** (shared helper)

- **Issue:** Same pattern as mail carrier: ternary "(setting == Female) ? Female : Male" — with enum, **"Both = 0"** was treated as **male**.
- **Change:** static class **"ServiceNpcGender"** in **"Oniki.Utilities/GameTools.cs"** (same file that already imports "Sims3.Gameplay.Utilities" / "RandomUtil"). Method **"CreateGenderFlags(Genders)"**; **"KWMailCarrier"**, **"KWPizzaDelivery"**, **"KWRepairman"** use it in "CreateNewNPCForPool"; **Both** ⇒ male/female with equal probability.
- **Note:** **"KWMaid"** (if present in the build) may still use the old ternary; optional alignment in a later wave.

---

## Repository note: source folder "445 v088" vs build **447**

- The workspace folder may remain named **"Source Code (445 v088)"** for build tool path continuity; content matches the **447 / v0.9.0 release line** (aligned with in-game "Oniki.KinkyMod v0.9.0 (447)").

---

## UI: "OptionMenu" / "PickerDialog" — NRE in "ObjectPicker" ("RepopulateHeaders" → "AddColumn")

### Issue (reference log)

- **ScriptError (example):** stack "Oniki.Services.FunctionTask+KWFunction" → "OptionMenu.Show" → "PickerDialog..ctor" → "ObjectPicker.Populate" → "TabContainer.Flush" / "SelectTab" → "RepopulateTable" → "RepopulateHeaders" → "TableContainer.AddColumn" → **"UIManager.AddChild" with null control** → "NullReferenceException".
- **Context:** settings menus / picker opens via "FunctionTask" using the "OptionMenu" → "PickerDialog" pipeline with "ObjectPicker".

### Likely KW-side causes (fixes applied)

- **"ShadyMenu"** passed **"mNumSelectableRows = -1"** to the "OptionMenu" / "PickerDialog" constructor. **"-1"** is only valid in other flows (e.g. multi-buy / "PurchaseDialog" with "Populate(..., -1)"), not the standard options picker layout: could push "ObjectPicker" into inconsistent state during populate/tab flush.
- **"OptionMenu.Show":** headers created with **"new HeaderInfo(caption, null, width)"** (tooltip always "null"); on some engine paths columns/headers behave better with non-null strings.
- **"CreateRow":** text cells from "item.GetColumn(i)" without coalescing; **"OptionSettingStage"** could expose **"Creator"** or **"Name"** as null.

### Change (code)

- **"Oniki.Interactions/ShadyMenu.cs":** last base constructor argument **"1"** instead of **"-1"** (explicit single selection).
- **"Oniki.UI/OptionMenu.cs" — "Show":**
  - per column: "caption = GetColumnLabel(i) ?? string.Empty";
  - "columnWidth = Math.Max(1, GetColumnSize(i))" (avoid width 0);
  - "new ObjectPicker.HeaderInfo(caption, caption, columnWidth)" (tooltip = caption, never null).
- **"Oniki.UI/OptionMenu.cs" — "CreateRow":** "new ObjectPicker.TextColumn((item.GetColumn(i)) ?? string.Empty)".
- **"Oniki.UI/OptionSettingStage.cs":** in "GetColumn", for columns 0 and 1: **"mStage.Creator ?? string.Empty"**, **"base.Name ?? string.Empty"**.

### Result

- Reduced / eliminated NRE on that UI path; "ShadyMenu" and options picker aligned with consistent "Populate" parameters and headers/cells non-null by construction.

---

## Careers: "KWGoToSchoolInRabbitHole" — vanilla NRE in "EventOpportunity.IsEligible" during school day

### Issue (reference log)

- **ScriptError (example):** stack "KWGoToSchoolInRabbitHole.Run" → "RabbitHoleInteraction.Run" → "GoToSchoolInRabbitHole.InRabbitHole" → "School.StartWorking" → "Career.StartWorking" → "CareerEventManager.RollForEvent" → **"Career.EventOpportunity.IsEligible"** → **"NullReferenceException"** (EA engine: career/event/opportunity data potentially inconsistent on save or due to CC).
- **Note:** root is in **vanilla** code inside "IsEligible"; KW does not introduce that dereference, but "EnterSchool()" triggers the path that hits it.

### Change (code) — ScriptError mitigation

- **"Oniki.Careers/KWGoToSchoolInRabbitHole.cs":**
  - before lot comparison: **"Target.RabbitHoleProxy != null"** (avoid NRE if proxy is missing).
  - **"EnterSchool()"** on **"HighSchoolStudent"** wrapped in **try/catch**: log via **"Log.Write(Actor.SimDescription, exception)"**, **"return false"** on the "already on school lot" path on exception (no unhandled exception to script VM).
  - in **"RouteNearEntrance" "finally":** same checks ("RabbitHoleProxy", non-null cast) and **try/catch** around **"EnterSchool()"** to avoid ScriptError if the exception appears from that point.

### Result / limitation

- **ScriptError avoided** when the engine throws from that branch; the Sim may **not complete** school entry in that frame (degraded but stable behavior).
- **EA data fix** (corrupted career/events/opportunities) still relies on clean save, maintenance tools, or diagnosis outside KW.

---

## Aging: "KWAgeUp" / "OpportunityManager.CancelOpportunityByCategory" (diagnosis only, no patch in this wave)

### Reference log

- **ScriptError (example):** "KWAgeUp.Run" → "AgeUp.Run" / "RunNonPersistable" → "AgingState.AgeTransitionCompleted" → **"OpportunityManager.CancelOpportunityByCategory"** → **"NullReferenceException"** (vanilla).
- **KW:** "AgeState.KWAgeUp" replaces "AgeUp" and already performs targeted "OpportunityManager" cleanup (career) before "base.Run()", but **does not** cover the inside of "CancelOpportunityByCategory" called by the engine in "AgeTransitionCompleted".

### Conclusion

- Error attributable to **EA engine** / opportunity state on "SimDescription", not to a KW callsite identified beyond the "AgeUp" wrapper.
- **No source change** added in "CHANGELOG" for this item in the session that produced the UI/school patches above; future hardening should be weighed against stable repro and side-effect cost on aging.

---
